{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Building a Controller"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "The following documents the development of a new controller.\n",
    "In this case we are going to implement an arbitrary controllable storage unit. This\n",
    "may be a battery, an electrically powered car or some sort of reservoir storage."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Modelling a Battery\n",
    "\n",
    "In order to simulate a storage system we use the storage element of pandapower. The power of a storage can be positive or negative. To overcome this issue, a storage element can be created. \n",
    "\n",
    "For storage elements the signing is based on the consumer viewpoint (positive active power means power consumption and therefore charging of the battery).\n",
    "\n",
    "As pandapower is not a time dependend simulation tool and there is no time domain parameter in default power flow calculations, the state of charge (SoC) is not updated during any power flow calculation. \n",
    "In Order to update the SoC we build our own storage controller and keep track of the SoC.\n",
    "\n",
    "State of charge (SoC [\\%]) is the level of charge of an electric battery relative to its capacity.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Controller init\n",
    "First we start by creating a new file *control/storage_control.py*, containing our new class."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Import and inherit from the parent class `Controller` and override methods you would like to use. Next we write the actual code for the methods."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "ExecuteTime": {
     "end_time": "2025-10-17T08:31:48.910819Z",
     "start_time": "2025-10-17T08:31:44.298752Z"
    }
   },
   "outputs": [],
   "source": [
    "from pandapower import control\n",
    "from pandapower.run import runpp\n",
    "from pandapower.create import create_storage\n",
    "import pandas as pd\n",
    "from pandapower import timeseries as ts\n",
    "\n",
    "# importing a grid from the library\n",
    "from pandapower.networks import mv_oberrhein\n",
    "\n",
    "\n",
    "class Storage(control.basic_controller.Controller):\n",
    "    \"\"\"\n",
    "        Example class of a Storage-Controller. Models an abstract energy storage.\n",
    "    \"\"\"\n",
    "    def __init__(self, net, element_index, data_source=None, p_profile=None, in_service=True,\n",
    "                 recycle=False, order=0, level=0, **kwargs):\n",
    "        super().__init__(net, in_service=in_service, recycle=recycle, order=order, level=level,\n",
    "                    initial_run = True)\n",
    "        \n",
    "        # read generator attributes from net\n",
    "        self.element_index = element_index  # index of the controlled storage\n",
    "        self.bus = net.storage.at[element_index, \"bus\"]\n",
    "        self.p_mw = net.storage.at[element_index, \"p_mw\"]\n",
    "        self.q_mvar = net.storage.at[element_index, \"q_mvar\"]\n",
    "        self.sn_mva = net.storage.at[element_index, \"sn_mva\"]\n",
    "        self.name = net.storage.at[element_index, \"name\"]\n",
    "        self.gen_type = net.storage.at[element_index, \"type\"]\n",
    "        self.in_service = net.storage.at[element_index, \"in_service\"]\n",
    "        self.applied = False\n",
    "\n",
    "        # specific attributes\n",
    "        self.max_e_mwh = net.storage.at[element_index, \"max_e_mwh\"]\n",
    "        self.soc_percent = net.storage.at[element_index, \"soc_percent\"] = 0\n",
    "        \n",
    "        # profile attributes\n",
    "        self.data_source = data_source\n",
    "        self.p_profile = p_profile\n",
    "        self.last_time_step = None\n",
    "        \n",
    "    # We choose to represent the storage-unit as a storage element in pandapower. \n",
    "    # We start with a function calculating the amout of stored energy:    \n",
    "    def get_stored_ernergy(self):\n",
    "        # calculating the stored energy\n",
    "        return self.max_e_mwh * self.soc_percent / 100        \n",
    "    \n",
    "    # convergence check\n",
    "    # Also remember that 'is_converged()' returns the boolean value of convergence:\n",
    "    def is_converged(self, net):\n",
    "        # check if controller already was applied\n",
    "        return self.applied\n",
    "    \n",
    "    # Also a first step we want our controller to be able to write its P and Q and state of charge values back to the\n",
    "    # data structure net.\n",
    "    def write_to_net(self, net):\n",
    "        # write p, q and soc_percent to bus within the net\n",
    "        net.storage.at[self.element_index, \"p_mw\"] = self.p_mw\n",
    "        net.storage.at[self.element_index, \"q_mvar\"] = self.q_mvar\n",
    "        net.storage.at[self.element_index, \"soc_percent\"]= self.soc_percent\n",
    "        \n",
    "    # In case the controller is not yet converged, the control step is executed. In the example it simply\n",
    "    # adopts a new value according to the previously calculated target and writes back to the net.\n",
    "    def control_step(self, net):\n",
    "        # Call write_to_net and set the applied variable True\n",
    "        self.write_to_net(net)\n",
    "        self.applied = True\n",
    "        \n",
    "    # In a time-series simulation the battery should read new power values from a profile and keep track\n",
    "    # of its state of charge as depicted below.\n",
    "    def time_step(self, net, time):\n",
    "        # keep track of the soc (assuming time is given in 15min values)\n",
    "        if self.last_time_step is not None:\n",
    "            # The amount of Energy produce or consumed in the last timestep is added relative to the \n",
    "            # maximum of the possible stored energy\n",
    "            self.soc_percent += (self.p_mw * (time-self.last_time_step) * 15 / 60) / self.max_e_mwh * 100\n",
    "        self.last_time_step = time\n",
    "\n",
    "        # read new values from a profile\n",
    "        if self.data_source:\n",
    "            if self.p_profile is not None:\n",
    "                self.p_mw = self.data_source.get_time_step_value(time_step=time,\n",
    "                                                                profile_name=self.p_profile)\n",
    "                \n",
    "        self.applied = False # reset applied variable"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "We are now ready to create objects of our newly implemented class and simulate with it!"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "ExecuteTime": {
     "end_time": "2025-10-17T08:31:53.341012Z",
     "start_time": "2025-10-17T08:31:48.917969Z"
    }
   },
   "outputs": [],
   "source": [
    "# importing a grid from the library\n",
    "from pandapower.networks import mv_oberrhein\n",
    "\n",
    "# loading the network with the usecase 'generation'\n",
    "net = mv_oberrhein()\n",
    "runpp(net)\n",
    "\n",
    "# creating a simple time series\n",
    "framedata = pd.DataFrame([0.1, .05, 0.1, .005, -0.2, 0], columns=['P'])\n",
    "datasource = ts.DFData(framedata)\n",
    "\n",
    "# creating storage unit in the grid, which will be controlled by our controller\n",
    "store_el = create_storage(net, 30, p_mw = .1, q_mvar = 0, max_e_mwh = 0.1, )\n",
    "\n",
    "# creating an Object of our new build storage controller, controlling the storage unit\n",
    "ctrl = Storage(net=net, element_index=store_el, data_source=datasource,p_profile='P')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Now we run a small time-series-simulation and track the results using the outputwriter:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "ExecuteTime": {
     "end_time": "2025-10-17T08:31:53.869430Z",
     "start_time": "2025-10-17T08:31:53.350129Z"
    }
   },
   "outputs": [],
   "source": [
    "# defining an OutputWriter to track certain variables\n",
    "ow = ts.OutputWriter(net)\n",
    "\n",
    "ow.log_variable(\"res_storage\", \"p_mw\")\n",
    "ow.log_variable(\"storage\", \"soc_percent\")\n",
    "\n",
    "# starting time series simulation\n",
    "ts.run_timeseries(net, time_steps=range(0, 6))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "To visualize the results we plot directly with the dataframe:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "ExecuteTime": {
     "end_time": "2025-10-17T08:31:54.087930Z",
     "start_time": "2025-10-17T08:31:53.875924Z"
    }
   },
   "outputs": [],
   "source": [
    "# plotting the state of charge\n",
    "ow.output['storage.soc_percent'].columns = ['Battery']\n",
    "ax = ow.output['storage.soc_percent'].plot()\n",
    "ax.set_xlabel('Time in 15min Steps')\n",
    "ax.set_ylabel('State of Charge in %')\n",
    "ax.legend()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "The state of charge grows until a certain point and drops in the end following the give values from the timeseries. The time windows between each timestep and the reactive power values define the amount of stored or consumed energy.\n",
    "\n",
    "In the shown case a SoC of more than 100\\% would be possible, because the SoC just gets summed up.\n",
    "To make the controller more realistic you could implement tresholds for the SoC and different initial SoC-values. Try to experiment!"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3 (ipykernel)",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3"
  },
  "vscode": {
   "interpreter": {
    "hash": "19d1d53a962d236aa061289c2ac16dc8e6d9648c89fe79f459ae9a3493bc67b4"
   }
  }
 },
 "nbformat": 4,
 "nbformat_minor": 4
}
